/*
 * Unidata Platform Community Edition
 * Copyright (c) 2013-2020, UNIDATA LLC, All rights reserved.
 * This file is part of the Unidata Platform Community Edition software.
 *
 * Unidata Platform Community Edition is free software: you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation, either version 3 of the License, or
 * (at your option) any later version.
 *
 * Unidata Platform Community Edition is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program. If not, see <https://www.gnu.org/licenses/>.
 */

package org.unidata.mdm.data.service.segments;

import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.Objects;

import org.apache.commons.lang3.BooleanUtils;
import org.unidata.mdm.core.type.timeline.TimeInterval;
import org.unidata.mdm.core.type.timeline.Timeline;
import org.unidata.mdm.data.context.DeleteRelationRequestContext;
import org.unidata.mdm.data.context.DeleteRelationRequestContext.DeleteRelationHint;
import org.unidata.mdm.data.context.DeleteRequestContext;
import org.unidata.mdm.data.context.GetRecordTimelineRequestContext;
import org.unidata.mdm.data.context.GetRelationRequestContext;
import org.unidata.mdm.data.context.GetRequestContext;
import org.unidata.mdm.data.context.RelationRestoreContext;
import org.unidata.mdm.data.context.RelationRestoreContext.RestoreRelationHint;
import org.unidata.mdm.data.context.RestoreRecordRequestContext;
import org.unidata.mdm.data.context.UpsertRelationRequestContext;
import org.unidata.mdm.data.context.UpsertRelationRequestContext.UpsertRelationHint;
import org.unidata.mdm.data.context.UpsertRequestContext;
import org.unidata.mdm.data.exception.DataExceptionIds;
import org.unidata.mdm.data.exception.DataProcessingException;
import org.unidata.mdm.data.service.DataRecordsService;
import org.unidata.mdm.data.service.impl.CommonRelationsComponent;
import org.unidata.mdm.data.service.impl.RelationDraftProviderComponent;
import org.unidata.mdm.data.type.apply.RecordDeleteChangeSet;
import org.unidata.mdm.data.type.apply.RecordRestoreChangeSet;
import org.unidata.mdm.data.type.apply.RecordUpsertChangeSet;
import org.unidata.mdm.data.type.data.EtalonRecord;
import org.unidata.mdm.data.type.data.EtalonRelation;
import org.unidata.mdm.data.type.data.EtalonRelationInfoSection;
import org.unidata.mdm.data.type.data.OriginRecord;
import org.unidata.mdm.data.type.data.OriginRelation;
import org.unidata.mdm.data.type.data.impl.EtalonRelationImpl;
import org.unidata.mdm.data.type.draft.DataDraftParameters;
import org.unidata.mdm.data.type.keys.RecordKeys;
import org.unidata.mdm.data.type.keys.RelationKeys;
import org.unidata.mdm.data.type.timeline.RelationTimeInterval;
import org.unidata.mdm.data.type.timeline.RelationTimeline;
import org.unidata.mdm.draft.context.DraftUpsertContext;
import org.unidata.mdm.draft.service.DraftService;
import org.unidata.mdm.system.context.CommonRequestContext;

/**
 * @author Mikhail Mikhailov on Dec 9, 2019
 */
public interface ContainmentRelationSupport {
    /**
     * Mirrors containment record's timeline to its relation counterpart.
     * Note, origin records are not converted as of now.
     * @param keys the relation key
     * @param original the original timeline
     * @return mirrored timeline
     */
    default Timeline<OriginRelation> mirror(RelationKeys keys, Timeline<OriginRecord> original) {

        Timeline<OriginRelation> mirror = new RelationTimeline(keys);
        if (Objects.nonNull(original)) {

            List<EtalonRelation> relations = new ArrayList<>(original.size());
            for (TimeInterval<OriginRecord> interval : original) {

                EtalonRecord record = interval.getCalculationResult();
                EtalonRelation relation = new EtalonRelationImpl()
                    .withDataRecord(record)
                    .withInfoSection(new EtalonRelationInfoSection()
                            .withCreateDate(record.getInfoSection().getCreateDate())
                            .withUpdateDate(record.getInfoSection().getUpdateDate())
                            .withCreatedBy(record.getInfoSection().getCreatedBy())
                            .withUpdatedBy(record.getInfoSection().getUpdatedBy())
                            .withStatus(record.getInfoSection().getStatus())
                            .withOperationType(record.getInfoSection().getOperationType())
                            .withPeriodId(record.getInfoSection().getPeriodId())
                            .withValidFrom(record.getInfoSection().getValidFrom())
                            .withValidTo(record.getInfoSection().getValidTo())
                            .withRelationEtalonKey(keys.getEtalonKey().getId())
                            .withRelationName(keys.getRelationName())
                            .withFromEtalonKey(keys.getEtalonKey().getFrom())
                            .withFromEntityName(keys.getFromEntityName())
                            .withToEtalonKey(keys.getEtalonKey().getTo())
                            .withToEntityName(keys.getToEntityName())
                            .withRelationType(keys.getRelationType()));

                relations.add(relation);

                TimeInterval<OriginRelation> i = new RelationTimeInterval(interval.getValidFrom(), interval.getValidTo(), Collections.emptyList());

                i.setCalculationResult(relation);
                i.setActive(interval.isActive());

                mirror.add(i);
            }
        }

        return mirror;
    }
    /**
     * Upsert containment (covers both 'before by external ID and 'after by etalonID').
     * @param ctx the relation context
     */
    default void upsert(UpsertRelationRequestContext ctx) {

        // We have to do it here for containments
        boolean newDraftId = false;
        if (ctx.isDraftOperation()) {
            newDraftId = ctx.hasDraftId();
            draftProviderComponent().ensureDraftId(ctx);
            newDraftId = !newDraftId;
        }

        RelationKeys keys = ctx.relationKeys();
        UpsertRequestContext uCtx = UpsertRequestContext.builder()
                .parentDraftId(ctx.getDraftId())
                .record(ctx.getRecord())
                .etalonKey(keys != null ? keys.getEtalonKey().getTo().getId() : ctx.getEtalonKey())
                .externalId(keys != null ? keys.getOriginKey().getTo().toExternalId() : ctx.getExternalIdAsObject())
                .validFrom(ctx.getValidFrom())
                .validTo(ctx.getValidTo())
                .batchOperation(true) // Postpone record's change set application until relation's set application
                .recalculateWholeTimeline(ctx.isRecalculateTimeline())
                .operationId(ctx.getOperationId())
                .build();

        uCtx.operationType(ctx.operationType());
        uCtx.timestamp(ctx.timestamp());

        // If this is a draft publish upsert, this is signalled by the hint
        // Since containment draft publishing doesn't insert any real data,
        // just get the timeline and repackage fields.
        if (BooleanUtils.isTrue(ctx.getHint(UpsertRelationHint.HINT_PUBLISHING))) {
            repackageUpsertContainment(uCtx, ctx);
            return;
        }

        try {
            dataRecordsService().upsertRecord(uCtx);
        } catch (Exception exc) {
            throwUpsertContainmentFailed(ctx, exc);
        }

        // Gather keys and context
        RecordKeys toRecordKeys = uCtx.keys();

        ctx.keys(toRecordKeys);
        ctx.containmentContext(uCtx);

        // New draft was created without subject ID and must be updated before doing draft lookups.
        // Otherwise existing relations will not be found and wll be recreated each time the new draft is updated.
        // See, if this record is an existing one and update subject ID, if so.
        if (newDraftId && Objects.isNull(keys)) {

            keys = commonRelationsComponent().identify(ctx.relationName(), ctx.fromKeys(), toRecordKeys);
            if (Objects.nonNull(keys)) {

                draftService().upsert(DraftUpsertContext.builder()
                        .draftId(ctx.getDraftId())
                        .parameter(DataDraftParameters.SUBJECT_ID, keys.getEtalonKey().getId())
                        .parameter(DataDraftParameters.ENTITY_NAME, keys.getRelationName())
                        .build());
            }
        }
    }
    /**
     * Delete containment.
     * @param ctx the relation context
     */
    default void delete(DeleteRelationRequestContext ctx) {

        // Rel keys must be already resolved.
        RelationKeys keys = ctx.relationKeys();

        // We have to do it here for containments
        if (ctx.isDraftOperation()) {
            draftProviderComponent().ensureDraftId(ctx);
        }

        DeleteRequestContext dCtx = DeleteRequestContext.builder()
                .parentDraftId(ctx.getDraftId())
                .etalonKey(keys.getEtalonKey().getTo().getId())
                .cascade(false)
                .validFrom(ctx.getValidFrom())
                .validTo(ctx.getValidTo())
                .inactivatePeriod(ctx.isInactivatePeriod())
                .inactivateEtalon(ctx.isInactivateEtalon())
                .inactivateOrigin(ctx.isInactivateOrigin())
                .wipe(ctx.isWipe())
                .operationId(ctx.getOperationId())
                .batchOperation(true) // Postpone record's change set application until relation's set application
                .build();

        dCtx.operationType(ctx.operationType());
        dCtx.timestamp(ctx.timestamp());

        // If this is a draft publish upsert, this is signalled by the hint
        // Since containment draft publishing doesn't insert any real data,
        // just get the timeline and repackage fields.
        if (BooleanUtils.isTrue(ctx.getHint(DeleteRelationHint.HINT_PUBLISHING))) {
            repackageDeleteContainment(dCtx, ctx);
            return;
        }

        try {
            dataRecordsService().deleteRecord(dCtx);
        } catch (Exception exc) {
            throwDeleteContainmentFailed(ctx, exc);
        }

        // Gather keys and context
        RecordKeys toRecordKeys = dCtx.keys();

        ctx.keys(toRecordKeys);
        ctx.containmentContext(dCtx);
    }
    /**
     * Get containment.
     * @param ctx the relation context
     */
    default void get(GetRelationRequestContext ctx) {

        RelationKeys keys = ctx.relationKeys();

        // We have to do it here for containments
        if (ctx.isDraftOperation()) {
            draftProviderComponent().ensureDraftId(ctx);
        }

        GetRequestContext gCtx = GetRequestContext.builder()
                .parentDraftId(ctx.getDraftId())
                .etalonKey(keys.getEtalonKey().getTo())
                .originKey(keys.getOriginKey().getTo())
                .forOperationId(ctx.getForOperationId())
                .forDate(ctx.getForDate())
                .forLastUpdate(ctx.getForLastUpdate())
                .updatesAfter(ctx.getForUpdatesAfter())
                .operationId(ctx.getOperationId())
                .fetchLargeObjects(true)
                .fetchTimelineData(true)
                .build();

        try {

            dataRecordsService().getRecord(gCtx);

            // Replace current timeline with the mirrored one
            // if everything went ok
            ctx.currentTimeline(mirror(keys, gCtx.currentTimeline()));

        } catch (Exception exc) {
            throwGetContainmentFailed(ctx, exc);
        }

        ctx.containmentContext(gCtx);
    }

    default void restore(RelationRestoreContext ctx) {

        RelationKeys keys = ctx.relationKeys();

        // We have to do it here for containments
        if (ctx.isDraftOperation()) {
            draftProviderComponent().ensureDraftId(ctx);
        }

        RestoreRecordRequestContext rCtx = RestoreRecordRequestContext.builder()
                .parentDraftId(ctx.getDraftId())
                .etalonKey(keys.getEtalonKey().getTo())
                .originKey(keys.getOriginKey().getTo())
                .validFrom(ctx.getValidFrom())
                .validTo(ctx.getValidTo())
                .periodRestore(ctx.isPeriodRestore())
                .forDate(ctx.getForDate())
                .lastUpdate(ctx.getLastUpdate())
                .operationId(((CommonRequestContext) ctx).getOperationId())
                .batchOperation(true) // Generate, but do not apply changes
                .build();

        rCtx.operationType(ctx.operationType());
        rCtx.timestamp(ctx.timestamp());

        // If this is a draft publish upsert, this is signalled by the hint
        // Since containment draft publishing doesn't insert any real data,
        // just get the timeline and repackage fields.
        if (BooleanUtils.isTrue(ctx.getHint(RestoreRelationHint.HINT_PUBLISHING))) {
            repackageRestoreContainment(rCtx, ctx);
            return;
        }

        try {
            dataRecordsService().restore(rCtx);
        } catch (Exception exc) {
            throwRestoreContainmentFailed(ctx, exc);
        }

        ctx.containmentContext(rCtx);
    }
    /**
     * Data records component instance.
     * @return {@link DataRecordsService} instance
     */
    default DataRecordsService dataRecordsService() {
        return null;
    }
    /**
     * Draft service.
     * @return {@link DraftService} instance
     */
    default DraftService draftService() {
        return null;
    }
    /**
     * Draft provider component instance.
     * @return {@link RelationDraftProviderComponent} instance
     */
    default RelationDraftProviderComponent draftProviderComponent() {
        return null;
    }
    /**
     * Common relation component.
     * @return {@link CommonRelationsComponent} instance
     */
    default CommonRelationsComponent commonRelationsComponent() {
        return null;
    }

    private void repackageUpsertContainment(UpsertRequestContext from, UpsertRelationRequestContext to) {

        Timeline<OriginRecord> timeline = dataRecordsService().loadTimeline(GetRecordTimelineRequestContext.builder(from)
                .draftId(from.getDraftId())
                .parentDraftId(from.getParentDraftId())
                .fetchData(true)
                .build());

        from.currentTimeline(timeline);
        from.nextTimeline(timeline);
        from.changeSet(new RecordUpsertChangeSet());
        from.keys(Objects.nonNull(timeline) ? timeline.getKeys() : null);

        to.containmentContext(from);
        to.keys(from.keys());
    }

    private void repackageDeleteContainment(DeleteRequestContext from, DeleteRelationRequestContext to) {
        // We don't even need the timeline
        // Just a few fields to suppress NPE
        from.changeSet(new RecordDeleteChangeSet());
        to.containmentContext(from);
    }

    private void repackageRestoreContainment(RestoreRecordRequestContext from, RelationRestoreContext to) {
        // We don't even need the timeline
        // Just a few fields to suppress NPE
        from.changeSet(new RecordRestoreChangeSet());
        to.containmentContext(from);
    }

    private void throwUpsertContainmentFailed(UpsertRelationRequestContext ctx, Exception exc) {
        final String relationName = ctx.relationName();
        final String message = "Containment record upsert to '{}' failed.";
        throw new DataProcessingException(message, exc, DataExceptionIds.EX_DATA_RELATIONS_UPSERT_CONTAINS_FAILED, relationName);
    }

    private void throwDeleteContainmentFailed(DeleteRelationRequestContext ctx, Exception exc) {
        final String relationName = ctx.relationName();
        final String message = "Containment record delete to '{}' failed.";
        throw new DataProcessingException(message, exc, DataExceptionIds.EX_DATA_RELATIONS_DELETE_CONTAINS_FAILED, relationName);
    }

    private void throwGetContainmentFailed(GetRelationRequestContext ctx, Exception exc) {
        final String relationName = ctx.relationName();
        final String message = "Containment record get to '{}' failed.";
        throw new DataProcessingException(message, exc, DataExceptionIds.EX_DATA_RELATIONS_GET_CONTAINS_FAILED, relationName);
    }

    private void throwRestoreContainmentFailed(RelationRestoreContext ctx, Exception exc) {
        final String relationName = ctx.relationName();
        final String message = "Containment record restore to '{}' failed.";
        throw new DataProcessingException(message, exc, DataExceptionIds.EX_DATA_RELATIONS_RESTORE_CONTAINS_FAILED, relationName);
    }
}
